Imports Microsoft.VisualBasic
Imports System
Imports System.Collections.Generic
Imports System.IO
Imports System.Linq
Imports System.Text
Imports System.Globalization

Namespace C4F.DevKit.Calendar.Parser
	''' <summary>
	''' A class used to read in iCal files.
	''' </summary>
	Public Class Reader
		#Region "Private Members"

		Private _streamReader As StreamReader

		Private _utcOffset As Integer

		Private _timezoneExists As Boolean

		#End Region

		#Region "Public Properties"

		''' <summary>
		''' The filestream attached to the iCal file to be used.
		''' </summary>
		Public WriteOnly Property FileStream() As FileStream
			Set(ByVal value As FileStream)
				_streamReader = New StreamReader(value)
			End Set
		End Property

		''' <summary>
		''' The path of the iCal file to be used.
		''' </summary>
		Public WriteOnly Property FilePath() As String
			Set(ByVal value As String)
				_streamReader = New StreamReader(value)
			End Set
		End Property

		#End Region

		#Region "Public Constructors"

		''' <summary>
		''' Creates a new calendar reader using the file specified.
		''' </summary>
		''' <param name="filepath">The iCal file to read.</param>
		Public Sub New(ByVal filepath As String)
			_streamReader = New StreamReader(filepath)
		End Sub

		''' <summary>
		''' Creates a new calendar reader using the StreamReader specified.
		''' </summary>
		''' <param name="streamReader">The StreamReader from which to read the iCal file.</param>
		Public Sub New(ByVal streamReader As StreamReader)
			_streamReader = streamReader
		End Sub

		''' <summary>
		''' Creates a new calendar reader using the FileStream specified.
		''' </summary>
		''' <param name="fileStream">The FileStream from which to read the iCal file.</param>
		Public Sub New(ByVal fileStream As FileStream)
			_streamReader = New StreamReader(fileStream)
		End Sub

		#End Region

		#Region "Public Methods"

		''' <summary>
		''' Reads an iCal file.
		''' </summary>
		''' <returns>A list of appointments from the calendar.</returns>
		Public Function ReadCalendar() As IEnumerable(Of Appointment)
			FindCalendarStart()

			Dim apptList As List(Of Appointment) = New List(Of Appointment)()
			_timezoneExists = MoveToTimeZone()

			If _timezoneExists Then
				GetUTCOffset()
				MoveToNextEvent()
			End If

			Do While Not _streamReader.EndOfStream
				Dim appt As Appointment = New Appointment()
				Dim currentLine As String = GetNextLine()

				Do While Not currentLine.EndsWith(CalendarConstants.END_APPOINTMENT)
					ProcessLine(currentLine, appt)
					currentLine = GetNextLine()
				Loop

				apptList.Add(appt)
				MoveToNextEvent()
			Loop

			CombineOccurrences(apptList)
			_streamReader.Close()

			Return apptList
		End Function

		#End Region

		#Region "Public Static Methods"

		''' <summary>
		''' Reads an iCal file.
		''' </summary>
		''' <param name="filepath">The path of the iCal file to read.</param>
		''' <returns>A list of appointments from the calendar.</returns>
		Public Shared Function ReadCalendar(ByVal filepath As String) As IEnumerable(Of Appointment)
			Dim reader As Reader = New Reader(filepath)
			Return reader.ReadCalendar()
		End Function

		''' <summary>
		''' Reads an iCal file.
		''' </summary>
		''' <param name="fileStream">The filestream attached to the iCal file to be read.</param>
		''' <returns>A list of appointments from the calendar.</returns>
		Public Shared Function ReadCalendar(ByVal fileStream As FileStream) As IEnumerable(Of Appointment)
			Dim reader As Reader = New Reader(fileStream)
			Return reader.ReadCalendar()
		End Function

		#End Region

		#Region "Private Methods"

		Private Sub FindCalendarStart()
			Dim currentLine As String = GetNextLine()

			Do While (Not currentLine.StartsWith(CalendarConstants.BEGIN_CALENDAR)) AndAlso Not _streamReader.EndOfStream
				currentLine = GetNextLine()
			Loop

			If _streamReader.EndOfStream Then
				Throw New CalendarException(String.Format("Invalid calendar file: missing {0}", CalendarConstants.BEGIN_CALENDAR.ToString()))
			End If
		End Sub

		''' <summary>
		''' Moves to the timezone component within this iCal file, if it exists.
		''' Otherwise, moves to the next event.
		''' </summary>
		''' <returns>True if the timezone component was found, false otherwise.</returns>
		Private Function MoveToTimeZone() As Boolean
			Dim currentLine As String = _streamReader.ReadLine()

			Do While Not(_streamReader.EndOfStream OrElse currentLine.StartsWith(CalendarConstants.BEGIN_TIMEZONE) OrElse currentLine.StartsWith(CalendarConstants.BEGIN_APPOINTMENT))
				currentLine = _streamReader.ReadLine()
			Loop

			Return currentLine.StartsWith(CalendarConstants.BEGIN_TIMEZONE)
		End Function

		Private Sub MoveToNextEvent()
			Do While Not(_streamReader.EndOfStream OrElse _streamReader.ReadLine().StartsWith(CalendarConstants.BEGIN_APPOINTMENT))
			Loop
		End Sub

		Private Sub GetUTCOffset()
			Dim currentLine As String = _streamReader.ReadLine()
			Dim offsetString1 As String = ""
			Dim offsetString2 As String = ""

			Do While String.IsNullOrEmpty(offsetString1) OrElse String.IsNullOrEmpty(offsetString2)
				Dim parts As String() = ParseFieldParts(currentLine)
				Dim offsetType As String = parts(0)
				Dim offset As String = parts(1)

				If offsetType.StartsWith(CalendarConstants.FROM_OFFSET) Then
					offsetString1 = offset
				ElseIf offsetType.StartsWith(CalendarConstants.TO_OFFSET) Then
					offsetString2 = offset
				End If

				currentLine = _streamReader.ReadLine()
			Loop

			Dim offset1 As Integer = Int32.Parse(offsetString1) / 100
			Dim offset2 As Integer = Int32.Parse(offsetString2) / 100
			If offset1 < offset2 Then
				_utcOffset = offset1
			Else
				_utcOffset = offset2
			End If
		End Sub

		Private Function GetNextLine() As String
			Dim line As String = _streamReader.ReadLine()

			Do While _streamReader.Peek() = CalendarConstants.ASCII_SPACE_CODE
				line = line & _streamReader.ReadLine().TrimStart(New Char() { " "c })
			Loop

			Return line
		End Function

		Private Sub ProcessLine(ByVal line As String, ByVal appt As Appointment)
			Dim fieldParts As String() = ParseFieldParts(line)
			Dim fieldName As String = fieldParts(0)
			Dim fieldValue As String = fieldParts(1)

			Select Case fieldName
				Case CalendarConstants.SUMMARY
					appt.Summary = ParserHelper.RemoveEscapeChars(fieldValue)

				Case CalendarConstants.LOCATION
					appt.Location = ParserHelper.RemoveEscapeChars(fieldValue)

				Case CalendarConstants.UID
					appt.SetUID(New Guid(fieldValue))

				Case CalendarConstants.URL
					' this gets rid of the quotes at either end
					Dim url As String = fieldValue.Remove(0, 1)
					url = url.Remove(url.Length - 1)
					appt.URL = url

				Case CalendarConstants.ATTENDEE
					appt.Attendees.Add(ParseAttendee(fieldValue))

				Case CalendarConstants.BEGIN
					appt.Reminder = ParseAlarm(appt.StartDateTime)

				Case CalendarConstants.TIMESTAMP
					appt.Timestamp = DateParser.ParseUTC(fieldValue)

				Case CalendarConstants.START_DATE_TIME
					appt.StartDateTime = ParseLocalDateTime(fieldValue)

					If fieldValue.Contains(CalendarConstants.DATE_VALUE) Then
						appt.IsAllDayEvent = True
					End If


				Case CalendarConstants.END_DATE_TIME
					appt.EndDateTime = ParseLocalDateTime(fieldValue)

				Case CalendarConstants.RECURRENCE_RULE
					appt.RecurrenceRule = ParseRecurrenceRule(fieldValue, CompareUTCToLocal(appt))

				Case CalendarConstants.NOTES
					appt.Notes = ParserHelper.RemoveEscapeChars(fieldValue)

				Case CalendarConstants.OCCURRENCE
					appt.Occurrence = ParseLocalDateTime(fieldValue)

				Case CalendarConstants.SEQUENCE_NUMBER
					appt.SequenceNumber = Int32.Parse(fieldValue)
					appt.IsPartOfSeries = True

				Case Else
					appt.ExtraProperties.Add(line)
			End Select
		End Sub

		Private Function ParseFieldParts(ByVal line As String) As String()
			Dim colonPos As Integer = line.IndexOfAny(New Char() {":"c, ";"c})
			Return New String() { line.Substring(0, colonPos), line.Substring(colonPos + 1, line.Length - colonPos - 1) }
		End Function

		Private Function ParseAttendee(ByVal line As String) As Attendee
			Dim attendee As Attendee = New Attendee()
			attendee.Name = ParserHelper.GetAttendeeName(line)
			attendee.EmailAddress = ParserHelper.GetEmailAddress(line)

			Return attendee
		End Function

		Private Function ParseAlarm(ByVal startDateTime As DateTime) As Reminder
			Dim reminder As Reminder = New Reminder()
			Dim currentLine As String = _streamReader.ReadLine()

			Do While Not currentLine.StartsWith(CalendarConstants.END_REMINDER)
				Dim fieldParts As String() = ParseFieldParts(currentLine)
				Dim fieldName As String = fieldParts(0)
				Dim fieldValue As String = fieldParts(1)

				Select Case fieldName
					Case CalendarConstants.TRIGGER
						ProcessTrigger(fieldValue, reminder)
					Case CalendarConstants.REMINDER_UID
						reminder.SetUID(New Guid(fieldValue))
					Case Else
						reminder.ExtraProperties.Add(currentLine)
				End Select

				currentLine = _streamReader.ReadLine()
			Loop

			Return reminder
		End Function

		Private Function ParseLocalDateTime(ByVal line As String) As DateTime
			Dim parts As String() = line.Split(New Char() { "="c, ":"c })

			If parts.Length = 1 Then
				Return DateParser.ParseUTC(parts(0))
			End If

			If parts(0).Equals(CalendarConstants.TIME_ZONE_ID) Then
				Return DateParser.ParseLocalTime(parts(2), _utcOffset)
			End If

			Return DateParser.ParseDate(parts(2))
		End Function

		Private Function ParseRecurrenceRule(ByVal line As String, ByVal UTCLocalDiff As Integer) As RecurrenceRule
			Dim properties As String() = line.Split(";"c)
			Dim recRule As RecurrenceRule = New RecurrenceRule()

			For Each [property] As String In properties
				Dim propertyParts As String() = [property].Split("="c)
				Dim propertyName As String = propertyParts(0)
				Dim propertyValue As String = propertyParts(1)

				Select Case propertyName
					Case CalendarConstants.FREQUENCY
						recRule.Frequency = ParseFrequency(propertyValue)
					Case CalendarConstants.COUNT
						recRule.Count = Int32.Parse(propertyValue)
					Case CalendarConstants.RECURRENCE_END_DATE_TIME
						recRule.EndDateTime = DateParser.ParseUTC(propertyValue)
					Case CalendarConstants.INTERVAL
						recRule.Interval = Int32.Parse(propertyValue)
					Case CalendarConstants.MONTH_WEEKDAY
						Dim monthWeekdayStrings As String() = propertyValue.Split(","c)

						For Each monthWeekdayString As String In monthWeekdayStrings
							Dim monthWeekday As MonthWeekday = ParseMonthWeekday(monthWeekdayString)
							Dim weekday As Weekday = monthWeekday.Weekday

							If UTCLocalDiff < 0 Then
								weekday = Decrement(weekday)
							ElseIf UTCLocalDiff > 0 Then
								weekday = Increment(weekday)
							End If

							monthWeekday.Weekday = weekday
							recRule.MonthWeekdays.Add(monthWeekday)
						Next monthWeekdayString

					Case CalendarConstants.MONTH_DATE
						recRule.MonthDate = Int32.Parse(propertyValue)
					Case Else
						recRule.ExtraProperties.Add([property])
				End Select
			Next [property]

			Return recRule
		End Function

		Private Function ParseMonthWeekday(ByVal line As String) As MonthWeekday
			Dim parts As String() = ParserHelper.GetMonthWeekdayParts(line)

			Dim monthWeekday As MonthWeekday = New MonthWeekday()

			Dim weekNumberString As String = parts(0)
			Dim weekNumber As Integer = 0

			If (Not weekNumberString.Equals(String.Empty)) Then
				weekNumber = Int32.Parse(weekNumberString)
			End If

			monthWeekday.WeekNumber = weekNumber
			monthWeekday.Weekday = ParseWeekday(parts(1))
			Return monthWeekday
		End Function

		Private Function ParseFrequency(ByVal freqString As String) As Frequency
			Select Case freqString
				Case "SECONDLY"
					Return Frequency.Secondly

				Case "MINUTELY"
					Return Frequency.Minutely

				Case "HOURLY"
					Return Frequency.Hourly

				Case "DAILY"
					Return Frequency.Daily

				Case "WEEKLY"
					Return Frequency.Weekly

				Case "MONTHLY"
					Return Frequency.Monthly

				Case "YEARLY"
					Return Frequency.Yearly

				Case Else
					Throw New ArgumentException("Frequency string must be one of: SECONDLY, MINUTELY," & "HOURLY, DAILY, WEEKLY, MONTHLY, YEARLY")
			End Select
		End Function

		Private Function ParseWeekday(ByVal weekdayString As String) As Weekday
			Select Case weekdayString
				Case "SU"
					Return Weekday.Sunday
				Case "MO"
					Return Weekday.Monday
				Case "TU"
					Return Weekday.Tuesday
				Case "WE"
					Return Weekday.Wednesday
				Case "TH"
					Return Weekday.Thursday
				Case "FR"
					Return Weekday.Friday
				Case "SA"
					Return Weekday.Saturday
				Case Else
					Throw New ArgumentException("Weekday string must be one of: SU, MO, TU, WE, " & "TH, FR, SA")
			End Select
		End Function

		Private Sub ProcessTrigger(ByVal line As String, ByVal reminder As Reminder)
			If line.StartsWith(CalendarConstants.DATE_TIME_VALUE) Then
				reminder.OnDateTrigger = DateParser.ParseUTC(ParseFieldParts(line)(1))
				Return
			End If

			Dim durationString As String = line.Substring(line.IndexOf("P"c) + 1)

			If String.IsNullOrEmpty(durationString) Then
				reminder.Trigger = New TimeSpan(0)
				Return
			End If

			If durationString.Chars(0) = "T"c Then
				durationString = durationString.Substring(1)
			End If

			Dim parts As String() = ParserHelper.GetDurationParts(durationString)
			Dim number As Integer = -1 * Int32.Parse(parts(0))
			Dim incrementType As Char = parts(1).Chars(0)

			Select Case incrementType
				Case "W"c
					reminder.Trigger = New TimeSpan(number * 7, 0, 0, 0)
					Return
				Case "D"c
					reminder.Trigger = New TimeSpan(number, 0, 0, 0)
					Return
				Case "H"c
					reminder.Trigger = New TimeSpan(number, 0, 0)
					Return
				Case "M"c
					reminder.Trigger = New TimeSpan(0, number, 0)
					Return
				Case "S"c
					reminder.Trigger = New TimeSpan(0, 0, number)
					Return
				Case Else
					Throw New ArgumentException("Increment type must be one of W, D, H, M, S")
			End Select
		End Sub

		Private Sub CombineOccurrences(ByVal appts As List(Of Appointment))
			Dim seriesEvents As Dictionary(Of Guid, Appointment) = New Dictionary(Of Guid, Appointment)()

			For Each appt As Appointment In appts
				If appt.IsPartOfSeries AndAlso appt.SequenceNumber = 0 Then
					seriesEvents.Add(appt.UID, appt)
				End If
			Next appt

			Dim apptsToRemove As List(Of Appointment) = New List(Of Appointment)()

			For Each appt As Appointment In appts
				If appt.IsPartOfSeries AndAlso appt.SequenceNumber = 1 Then
					apptsToRemove.Add(appt)
					seriesEvents(appt.UID).Occurrences.Add(appt)
				End If
			Next appt

			For Each appt As Appointment In apptsToRemove
				appts.Remove(appt)
			Next appt
		End Sub

		Private Function CompareUTCToLocal(ByVal appt As Appointment) As Integer
			Dim offset As Integer
			If DateTime.SpecifyKind(appt.StartDateTime, DateTimeKind.Unspecified).IsDaylightSavingTime() Then
				offset = _utcOffset + (1)
			Else
				offset = _utcOffset + (0)
			End If
			Return appt.StartDateTime.Date.CompareTo((appt.StartDateTime + New TimeSpan(offset, 0, 0)).Date)
		End Function

		Private Function Increment(ByVal weekday As Weekday) As Weekday
			Dim newNumber As Integer = ((CInt(Fix(weekday))) + 1) Mod 7
			Return CType(newNumber, Weekday)
		End Function

		Private Function Decrement(ByVal weekday As Weekday) As Weekday
			Dim newNumber As Integer = ((CInt(Fix(weekday))) + 6) Mod 7
			Return CType(newNumber, Weekday)
		End Function

		#End Region
	End Class
End Namespace